backspin 0.3.0 → 0.4.1
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/.circleci/config.yml +3 -1
- data/CHANGELOG.md +4 -0
- data/CLAUDE.md +6 -6
- data/CONTRIBUTING.md +3 -5
- data/Gemfile +1 -1
- data/Gemfile.lock +2 -2
- data/README.md +113 -34
- data/backspin.gemspec +2 -2
- data/bin/rake +27 -0
- data/bin/rspec +27 -0
- data/fixtures/backspin/all_mode_filter.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/full_data_filter.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/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 +33 -14
- data/lib/backspin/command_diff.rb +88 -0
- data/lib/backspin/command_result.rb +60 -0
- data/lib/backspin/record.rb +2 -2
- data/lib/backspin/record_result.rb +153 -0
- data/lib/backspin/recorder.rb +4 -23
- data/lib/backspin/version.rb +1 -1
- data/lib/backspin.rb +169 -287
- metadata +33 -4
data/lib/backspin/recorder.rb
CHANGED
@@ -6,6 +6,7 @@ module Backspin
|
|
6
6
|
# Handles stubbing and recording of command executions
|
7
7
|
class Recorder
|
8
8
|
include RSpec::Mocks::ExampleMethods
|
9
|
+
SUPPORTED_COMMAND_TYPES = [:capture3, :system]
|
9
10
|
|
10
11
|
attr_reader :commands, :verification_data, :mode, :record
|
11
12
|
|
@@ -17,7 +18,7 @@ module Backspin
|
|
17
18
|
end
|
18
19
|
|
19
20
|
def record_calls(*command_types)
|
20
|
-
command_types =
|
21
|
+
command_types = SUPPORTED_COMMAND_TYPES if command_types.empty?
|
21
22
|
|
22
23
|
command_types.each do |command_type|
|
23
24
|
record_call(command_type)
|
@@ -31,7 +32,7 @@ module Backspin
|
|
31
32
|
when :capture3
|
32
33
|
setup_capture3_call_stub
|
33
34
|
else
|
34
|
-
raise ArgumentError, "
|
35
|
+
raise ArgumentError, "Unsupported command type: #{command_type} - currently supported types: #{SUPPORTED_COMMAND_TYPES.join(", ")}"
|
35
36
|
end
|
36
37
|
end
|
37
38
|
|
@@ -66,7 +67,6 @@ module Backspin
|
|
66
67
|
# For system calls, we only track the exit status
|
67
68
|
@verification_data["stdout"] = ""
|
68
69
|
@verification_data["stderr"] = ""
|
69
|
-
# Derive exit status from result: true = 0, false = non-zero
|
70
70
|
@verification_data["status"] = result ? 0 : 1
|
71
71
|
|
72
72
|
result
|
@@ -107,12 +107,10 @@ module Backspin
|
|
107
107
|
allow_any_instance_of(Object).to receive(:system) do |receiver, *args|
|
108
108
|
command = @record.next_command
|
109
109
|
|
110
|
-
# Make sure this is a system command
|
111
110
|
unless command.method_class == ::Kernel::System
|
112
111
|
raise RecordNotFoundError, "Expected Kernel::System command but got #{command.method_class.name}"
|
113
112
|
end
|
114
113
|
|
115
|
-
# Return true if exit status was 0, false otherwise
|
116
114
|
command.status == 0
|
117
115
|
rescue NoMoreRecordingsError => e
|
118
116
|
raise RecordNotFoundError, e.message
|
@@ -121,17 +119,14 @@ module Backspin
|
|
121
119
|
|
122
120
|
def setup_capture3_call_stub
|
123
121
|
allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
|
124
|
-
# Execute the real command
|
125
122
|
stdout, stderr, status = original_method.call(*args)
|
126
123
|
|
127
|
-
# Parse command args
|
128
124
|
cmd_args = if args.length == 1 && args.first.is_a?(String)
|
129
125
|
args.first.split(" ")
|
130
126
|
else
|
131
127
|
args
|
132
128
|
end
|
133
129
|
|
134
|
-
# Create command with interaction data
|
135
130
|
command = Command.new(
|
136
131
|
method_class: Open3::Capture3,
|
137
132
|
args: cmd_args,
|
@@ -142,17 +137,12 @@ module Backspin
|
|
142
137
|
)
|
143
138
|
@commands << command
|
144
139
|
|
145
|
-
# Store output for later access (last one wins)
|
146
|
-
Backspin.last_output = stdout
|
147
|
-
|
148
|
-
# Return original result
|
149
140
|
[stdout, stderr, status]
|
150
141
|
end
|
151
142
|
end
|
152
143
|
|
153
144
|
def setup_system_call_stub
|
154
145
|
allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
|
155
|
-
# Execute the real system call
|
156
146
|
result = original_method.call(receiver, *args)
|
157
147
|
|
158
148
|
# Parse command args based on how system was called
|
@@ -164,14 +154,9 @@ module Backspin
|
|
164
154
|
args
|
165
155
|
end
|
166
156
|
|
167
|
-
|
168
|
-
# The caller of system() doesn't have access to them
|
169
|
-
stdout = ""
|
170
|
-
stderr = ""
|
171
|
-
# Derive exit status from result: true = 0, false = non-zero, nil = command failed
|
157
|
+
stdout, stderr = "", ""
|
172
158
|
status = result ? 0 : 1
|
173
159
|
|
174
|
-
# Create command with interaction data
|
175
160
|
command = Command.new(
|
176
161
|
method_class: ::Kernel::System,
|
177
162
|
args: parsed_args,
|
@@ -182,10 +167,6 @@ module Backspin
|
|
182
167
|
)
|
183
168
|
@commands << command
|
184
169
|
|
185
|
-
# Store output for later access (for consistency with capture3)
|
186
|
-
Backspin.last_output = stdout
|
187
|
-
|
188
|
-
# Return the original result (true/false/nil)
|
189
170
|
result
|
190
171
|
end
|
191
172
|
end
|
data/lib/backspin/version.rb
CHANGED
data/lib/backspin.rb
CHANGED
@@ -5,9 +5,12 @@ require "pathname"
|
|
5
5
|
require "ostruct"
|
6
6
|
require "rspec/mocks"
|
7
7
|
require "backspin/version"
|
8
|
+
require "backspin/command_result"
|
8
9
|
require "backspin/command"
|
10
|
+
require "backspin/command_diff"
|
9
11
|
require "backspin/record"
|
10
12
|
require "backspin/recorder"
|
13
|
+
require "backspin/record_result"
|
11
14
|
|
12
15
|
module Backspin
|
13
16
|
class RecordNotFoundError < StandardError; end
|
@@ -18,7 +21,7 @@ module Backspin
|
|
18
21
|
# Configuration for Backspin
|
19
22
|
class Configuration
|
20
23
|
attr_accessor :scrub_credentials
|
21
|
-
# The directory where backspin will store its files - defaults to
|
24
|
+
# The directory where backspin will store its files - defaults to fixtures/backspin
|
22
25
|
attr_accessor :backspin_dir
|
23
26
|
# Regex patterns to scrub from saved output
|
24
27
|
attr_reader :credential_patterns
|
@@ -26,7 +29,7 @@ module Backspin
|
|
26
29
|
def initialize
|
27
30
|
@scrub_credentials = true
|
28
31
|
@credential_patterns = default_credential_patterns
|
29
|
-
@backspin_dir = Pathname(Dir.pwd).join("
|
32
|
+
@backspin_dir = Pathname(Dir.pwd).join("fixtures", "backspin")
|
30
33
|
end
|
31
34
|
|
32
35
|
def add_credential_pattern(pattern)
|
@@ -69,7 +72,9 @@ module Backspin
|
|
69
72
|
|
70
73
|
class << self
|
71
74
|
def configuration
|
72
|
-
@configuration
|
75
|
+
return @configuration if @configuration
|
76
|
+
|
77
|
+
@configuration = Configuration.new
|
73
78
|
end
|
74
79
|
|
75
80
|
def configure
|
@@ -79,86 +84,6 @@ module Backspin
|
|
79
84
|
def reset_configuration!
|
80
85
|
@configuration = Configuration.new
|
81
86
|
end
|
82
|
-
end
|
83
|
-
|
84
|
-
class Result
|
85
|
-
attr_reader :commands, :record_path
|
86
|
-
|
87
|
-
def initialize(commands:, record_path:)
|
88
|
-
@commands = commands
|
89
|
-
@record_path = record_path
|
90
|
-
end
|
91
|
-
end
|
92
|
-
|
93
|
-
class VerifyResult
|
94
|
-
attr_reader :record_path, :expected_output, :actual_output, :diff, :stderr_diff
|
95
|
-
|
96
|
-
def initialize(verified:, record_path:, expected_output: nil, actual_output: nil,
|
97
|
-
expected_stderr: nil, actual_stderr: nil, expected_status: nil, actual_status: nil,
|
98
|
-
command_executed: true)
|
99
|
-
@verified = verified
|
100
|
-
@record_path = record_path
|
101
|
-
@expected_output = expected_output
|
102
|
-
@actual_output = actual_output
|
103
|
-
@expected_stderr = expected_stderr
|
104
|
-
@actual_stderr = actual_stderr
|
105
|
-
@expected_status = expected_status
|
106
|
-
@actual_status = actual_status
|
107
|
-
@command_executed = command_executed
|
108
|
-
|
109
|
-
if !verified && expected_output && actual_output
|
110
|
-
@diff = generate_diff(expected_output, actual_output)
|
111
|
-
end
|
112
|
-
|
113
|
-
if !verified && expected_stderr && actual_stderr && expected_stderr != actual_stderr
|
114
|
-
@stderr_diff = generate_diff(expected_stderr, actual_stderr)
|
115
|
-
end
|
116
|
-
end
|
117
|
-
|
118
|
-
def verified?
|
119
|
-
@verified
|
120
|
-
end
|
121
|
-
|
122
|
-
def output
|
123
|
-
@actual_output
|
124
|
-
end
|
125
|
-
|
126
|
-
def error_message
|
127
|
-
return nil if verified?
|
128
|
-
|
129
|
-
"Output verification failed\nExpected: #{@expected_output.chomp}\nActual: #{@actual_output.chomp}"
|
130
|
-
end
|
131
|
-
|
132
|
-
def command_executed?
|
133
|
-
@command_executed
|
134
|
-
end
|
135
|
-
|
136
|
-
private
|
137
|
-
|
138
|
-
def generate_diff(expected, actual)
|
139
|
-
expected_lines = expected.lines
|
140
|
-
actual_lines = actual.lines
|
141
|
-
diff = []
|
142
|
-
|
143
|
-
# Simple diff for now
|
144
|
-
expected_lines.each do |line|
|
145
|
-
unless actual_lines.include?(line)
|
146
|
-
diff << "-#{line.chomp}"
|
147
|
-
end
|
148
|
-
end
|
149
|
-
|
150
|
-
actual_lines.each do |line|
|
151
|
-
unless expected_lines.include?(line)
|
152
|
-
diff << "+#{line.chomp}"
|
153
|
-
end
|
154
|
-
end
|
155
|
-
|
156
|
-
diff.join("\n")
|
157
|
-
end
|
158
|
-
end
|
159
|
-
|
160
|
-
class << self
|
161
|
-
attr_accessor :last_output
|
162
87
|
|
163
88
|
def scrub_text(text)
|
164
89
|
return text unless configuration.scrub_credentials && text
|
@@ -173,262 +98,219 @@ module Backspin
|
|
173
98
|
scrubbed
|
174
99
|
end
|
175
100
|
|
176
|
-
|
101
|
+
# Primary API - records on first run, verifies on subsequent runs
|
102
|
+
#
|
103
|
+
# @param record_name [String] Name for the record file
|
104
|
+
# @param options [Hash] Options for recording/verification
|
105
|
+
# @option options [Symbol] :mode (:auto) Recording mode - :auto, :record, :verify, :playback
|
106
|
+
# @option options [Proc] :filter Custom filter for recorded data
|
107
|
+
# @option options [Proc] :matcher Custom matcher for verification
|
108
|
+
# @return [RecordResult] Result object with output and status
|
109
|
+
def run(record_name, options = {}, &block)
|
177
110
|
raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
|
111
|
+
raise ArgumentError, "block is required" unless block_given?
|
178
112
|
|
179
113
|
record_path = build_record_path(record_name)
|
114
|
+
mode = determine_mode(options[:mode], record_path)
|
115
|
+
|
116
|
+
case mode
|
117
|
+
when :record
|
118
|
+
perform_recording(record_name, record_path, options, &block)
|
119
|
+
when :verify
|
120
|
+
perform_verification(record_name, record_path, options, &block)
|
121
|
+
when :playback
|
122
|
+
perform_playback(record_name, record_path, options, &block)
|
123
|
+
else
|
124
|
+
raise ArgumentError, "Unknown mode: #{mode}"
|
125
|
+
end
|
126
|
+
end
|
180
127
|
|
181
|
-
|
182
|
-
|
183
|
-
|
128
|
+
# Strict version of run that raises on verification failure
|
129
|
+
#
|
130
|
+
# @param record_name [String] Name for the record file
|
131
|
+
# @param options [Hash] Options for recording/verification
|
132
|
+
# @return [RecordResult] Result object with output and status
|
133
|
+
# @raise [RSpec::Expectations::ExpectationNotMetError] If verification fails
|
134
|
+
def run!(record_name, options = {}, &block)
|
135
|
+
result = run(record_name, options, &block)
|
184
136
|
|
185
|
-
|
137
|
+
if result.verified? == false
|
138
|
+
error_message = "Backspin verification failed!\n"
|
139
|
+
error_message += "Record: #{result.record_path}\n"
|
186
140
|
|
187
|
-
|
188
|
-
|
189
|
-
# Don't load existing data when creating new record
|
190
|
-
record = Record.new(record_path)
|
191
|
-
record.clear # Clear any loaded data
|
192
|
-
recorder.commands.each { |cmd| record.add_command(cmd) }
|
193
|
-
record.save(filter: filter)
|
141
|
+
# Use the error_message from the result which is now properly formatted
|
142
|
+
error_message += "\n#{result.error_message}" if result.error_message
|
194
143
|
|
195
|
-
|
196
|
-
|
144
|
+
raise RSpec::Expectations::ExpectationNotMetError, error_message
|
145
|
+
end
|
197
146
|
|
198
|
-
|
199
|
-
last_output
|
147
|
+
result
|
200
148
|
end
|
201
149
|
|
202
|
-
|
203
|
-
raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
|
150
|
+
private
|
204
151
|
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
when :none
|
211
|
-
# Never record, only replay
|
212
|
-
unless File.exist?(record_path)
|
213
|
-
raise RecordNotFoundError, "Record not found: #{record_path}"
|
214
|
-
end
|
215
|
-
replay_record(record_path, &block)
|
216
|
-
when :all
|
217
|
-
# Always record
|
218
|
-
record_and_save_record(record_path, filter: filter, &block)
|
219
|
-
when :once
|
220
|
-
# Record if doesn't exist, replay if exists
|
221
|
-
if File.exist?(record_path)
|
222
|
-
replay_record(record_path, &block)
|
223
|
-
else
|
224
|
-
record_and_save_record(record_path, filter: filter, &block)
|
225
|
-
end
|
226
|
-
when :new_episodes
|
227
|
-
# Record new commands not in record
|
228
|
-
# For now, simplified: just append new recordings
|
229
|
-
record_new_episode(record_path, filter: filter, &block)
|
230
|
-
else
|
231
|
-
raise ArgumentError, "Unknown record mode: #{record_mode}"
|
232
|
-
end
|
152
|
+
def determine_mode(mode_option, record_path)
|
153
|
+
return mode_option if mode_option && mode_option != :auto
|
154
|
+
|
155
|
+
# Auto mode: record if file doesn't exist, verify if it does
|
156
|
+
File.exist?(record_path) ? :verify : :record
|
233
157
|
end
|
234
158
|
|
235
|
-
def
|
236
|
-
|
159
|
+
def perform_recording(_record_name, record_path, options)
|
160
|
+
recorder = Recorder.new
|
161
|
+
recorder.record_calls(:capture3, :system)
|
237
162
|
|
238
|
-
|
239
|
-
unless record.exists?
|
240
|
-
raise RecordNotFoundError, "Record not found: #{record_path}"
|
241
|
-
end
|
163
|
+
output = yield
|
242
164
|
|
243
|
-
if
|
244
|
-
|
165
|
+
if output.is_a?(Array) && output.size == 3
|
166
|
+
stdout, stderr, status = output
|
167
|
+
status_int = status.respond_to?(:exitstatus) ? status.exitstatus : status
|
168
|
+
output = [stdout, stderr, status_int]
|
245
169
|
end
|
246
170
|
|
247
|
-
#
|
248
|
-
|
249
|
-
|
171
|
+
# Save the recording
|
172
|
+
FileUtils.mkdir_p(File.dirname(record_path))
|
173
|
+
record = Record.new(record_path)
|
174
|
+
record.clear
|
175
|
+
recorder.commands.each { |cmd| record.add_command(cmd) }
|
176
|
+
record.save(filter: options[:filter])
|
177
|
+
|
178
|
+
# Return result
|
179
|
+
RecordResult.new(
|
180
|
+
output: output,
|
181
|
+
mode: :record,
|
182
|
+
record_path: Pathname.new(record_path),
|
183
|
+
commands: recorder.commands
|
184
|
+
)
|
185
|
+
end
|
250
186
|
|
251
|
-
|
252
|
-
|
187
|
+
def perform_verification(_record_name, record_path, options)
|
188
|
+
record = Record.load_or_create(record_path)
|
253
189
|
|
254
|
-
|
255
|
-
|
256
|
-
recorder.setup_playback_stub(command)
|
257
|
-
|
258
|
-
yield
|
259
|
-
|
260
|
-
# In playback mode, always verified
|
261
|
-
VerifyResult.new(
|
262
|
-
verified: true,
|
263
|
-
record_path: Pathname.new(record_path),
|
264
|
-
expected_output: command.stdout,
|
265
|
-
actual_output: command.stdout,
|
266
|
-
expected_stderr: command.stderr,
|
267
|
-
actual_stderr: command.stderr,
|
268
|
-
expected_status: command.status,
|
269
|
-
actual_status: command.status,
|
270
|
-
command_executed: false
|
271
|
-
)
|
272
|
-
elsif matcher
|
273
|
-
# Custom matcher verification
|
274
|
-
recorder.setup_verification_stub(command)
|
275
|
-
|
276
|
-
yield
|
277
|
-
|
278
|
-
# Call custom matcher - convert command back to hash format for matcher
|
279
|
-
recorded_data = command.to_h
|
280
|
-
verified = matcher.call(recorded_data, recorder.verification_data)
|
281
|
-
|
282
|
-
VerifyResult.new(
|
283
|
-
verified: verified,
|
284
|
-
record_path: Pathname.new(record_path),
|
285
|
-
expected_output: command.stdout,
|
286
|
-
actual_output: recorder.verification_data["stdout"],
|
287
|
-
expected_stderr: command.stderr,
|
288
|
-
actual_stderr: recorder.verification_data["stderr"],
|
289
|
-
expected_status: command.status,
|
290
|
-
actual_status: recorder.verification_data["status"]
|
291
|
-
)
|
292
|
-
else
|
293
|
-
# Default strict mode
|
294
|
-
recorder.setup_verification_stub(command)
|
295
|
-
|
296
|
-
yield
|
297
|
-
|
298
|
-
# Compare outputs
|
299
|
-
actual_stdout = recorder.verification_data["stdout"]
|
300
|
-
actual_stderr = recorder.verification_data["stderr"]
|
301
|
-
actual_status = recorder.verification_data["status"]
|
302
|
-
|
303
|
-
verified =
|
304
|
-
command.stdout == actual_stdout &&
|
305
|
-
command.stderr == actual_stderr &&
|
306
|
-
command.status == actual_status
|
307
|
-
|
308
|
-
VerifyResult.new(
|
309
|
-
verified: verified,
|
310
|
-
record_path: Pathname.new(record_path),
|
311
|
-
expected_output: command.stdout,
|
312
|
-
actual_output: actual_stdout,
|
313
|
-
expected_stderr: command.stderr,
|
314
|
-
actual_stderr: actual_stderr,
|
315
|
-
expected_status: command.status,
|
316
|
-
actual_status: actual_status
|
317
|
-
)
|
318
|
-
end
|
319
|
-
end
|
190
|
+
raise RecordNotFoundError, "Record not found: #{record_path}" unless record.exists?
|
191
|
+
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
320
192
|
|
321
|
-
|
322
|
-
|
193
|
+
# For verification, we need to track all commands executed
|
194
|
+
recorder = Recorder.new(mode: :verify, record: record)
|
195
|
+
recorder.setup_replay_stubs
|
323
196
|
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
197
|
+
# Track verification results for each command
|
198
|
+
command_diffs = []
|
199
|
+
command_index = 0
|
200
|
+
|
201
|
+
# Override stubs to verify each command as it's executed
|
202
|
+
allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
|
203
|
+
recorded_command = record.commands[command_index]
|
329
204
|
|
330
|
-
if
|
331
|
-
|
205
|
+
if recorded_command.nil?
|
206
|
+
raise RecordNotFoundError, "No more recorded commands, but tried to execute: #{args.inspect}"
|
332
207
|
end
|
333
208
|
|
334
|
-
if
|
335
|
-
|
209
|
+
if recorded_command.method_class != Open3::Capture3
|
210
|
+
raise RecordNotFoundError, "Expected #{recorded_command.method_class.name} but got Open3.capture3"
|
336
211
|
end
|
337
212
|
|
338
|
-
#
|
339
|
-
|
340
|
-
end
|
213
|
+
# Execute the actual command
|
214
|
+
stdout, stderr, status = original_method.call(*args)
|
341
215
|
|
342
|
-
|
343
|
-
|
216
|
+
# Create verification result
|
217
|
+
actual_result = CommandResult.new(
|
218
|
+
stdout: stdout,
|
219
|
+
stderr: stderr,
|
220
|
+
status: status.exitstatus
|
221
|
+
)
|
344
222
|
|
345
|
-
|
223
|
+
# Create CommandDiff to track the comparison
|
224
|
+
command_diffs << CommandDiff.new(
|
225
|
+
recorded_command: recorded_command,
|
226
|
+
actual_result: actual_result,
|
227
|
+
matcher: options[:matcher]
|
228
|
+
)
|
346
229
|
|
347
|
-
|
348
|
-
|
349
|
-
unless record.exists?
|
350
|
-
raise RecordNotFoundError, "Record not found: #{record_path}"
|
230
|
+
command_index += 1
|
231
|
+
[stdout, stderr, status]
|
351
232
|
end
|
352
233
|
|
353
|
-
|
354
|
-
|
355
|
-
end
|
234
|
+
allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
|
235
|
+
recorded_command = record.commands[command_index]
|
356
236
|
|
357
|
-
|
358
|
-
|
359
|
-
|
237
|
+
if recorded_command.nil?
|
238
|
+
raise RecordNotFoundError, "No more recorded commands, but tried to execute: system #{args.inspect}"
|
239
|
+
end
|
360
240
|
|
361
|
-
|
241
|
+
if recorded_command.method_class != ::Kernel::System
|
242
|
+
raise RecordNotFoundError, "Expected #{recorded_command.method_class.name} but got system"
|
243
|
+
end
|
362
244
|
|
363
|
-
|
364
|
-
|
365
|
-
if block_return_value.is_a?(Array) && block_return_value.size == 3 &&
|
366
|
-
block_return_value[0].is_a?(String) && block_return_value[1].is_a?(String)
|
367
|
-
# Convert status to integer for consistency
|
368
|
-
stdout, stderr, status = block_return_value
|
369
|
-
status_int = status.respond_to?(:exitstatus) ? status.exitstatus : status
|
370
|
-
[stdout, stderr, status_int]
|
371
|
-
else
|
372
|
-
block_return_value
|
373
|
-
end
|
374
|
-
end
|
245
|
+
# Execute the actual command
|
246
|
+
result = original_method.call(receiver, *args)
|
375
247
|
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
248
|
+
# Create verification result (system only gives us exit status)
|
249
|
+
actual_result = CommandResult.new(
|
250
|
+
stdout: "",
|
251
|
+
stderr: "",
|
252
|
+
status: result ? 0 : 1
|
253
|
+
)
|
380
254
|
|
381
|
-
|
255
|
+
# Create CommandDiff to track the comparison
|
256
|
+
command_diffs << CommandDiff.new(
|
257
|
+
recorded_command: recorded_command,
|
258
|
+
actual_result: actual_result,
|
259
|
+
matcher: options[:matcher]
|
260
|
+
)
|
382
261
|
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
record = Record.new(record_path)
|
387
|
-
record.clear # Clear any loaded data
|
388
|
-
recorder.commands.each { |cmd| record.add_command(cmd) }
|
389
|
-
record.save(filter: filter)
|
262
|
+
command_index += 1
|
263
|
+
result
|
264
|
+
end
|
390
265
|
|
391
|
-
#
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
block_return_value
|
266
|
+
# Execute block
|
267
|
+
output = yield
|
268
|
+
|
269
|
+
# Check if all commands were executed
|
270
|
+
if command_index < record.commands.size
|
271
|
+
raise RecordNotFoundError, "Expected #{record.commands.size} commands but only #{command_index} were executed"
|
398
272
|
end
|
273
|
+
|
274
|
+
# Overall verification status
|
275
|
+
all_verified = command_diffs.all?(&:verified?)
|
276
|
+
|
277
|
+
RecordResult.new(
|
278
|
+
output: output,
|
279
|
+
mode: :verify,
|
280
|
+
verified: all_verified,
|
281
|
+
record_path: Pathname.new(record_path),
|
282
|
+
commands: record.commands,
|
283
|
+
command_diffs: command_diffs
|
284
|
+
)
|
399
285
|
end
|
400
286
|
|
401
|
-
def
|
402
|
-
# For new_episodes mode, we'd need to track which commands have been seen
|
403
|
-
# For now, simplified implementation that just appends
|
287
|
+
def perform_playback(_record_name, record_path, _options)
|
404
288
|
record = Record.load_or_create(record_path)
|
405
289
|
|
406
|
-
|
407
|
-
|
408
|
-
recorder.record_calls(:capture3, :system)
|
290
|
+
raise RecordNotFoundError, "Record not found: #{record_path}" unless record.exists?
|
291
|
+
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
409
292
|
|
410
|
-
|
293
|
+
# Setup replay mode - this will handle returning values for all commands
|
294
|
+
recorder = Recorder.new(mode: :replay, record: record)
|
295
|
+
recorder.setup_replay_stubs
|
411
296
|
|
412
|
-
#
|
413
|
-
|
414
|
-
recorder.commands.each { |cmd| record.add_command(cmd) }
|
415
|
-
record.save(filter: filter)
|
416
|
-
end
|
297
|
+
# Execute block (all commands will be stubbed with recorded values)
|
298
|
+
output = yield
|
417
299
|
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
300
|
+
RecordResult.new(
|
301
|
+
output: output,
|
302
|
+
mode: :playback,
|
303
|
+
verified: true, # Always true for playback
|
304
|
+
record_path: Pathname.new(record_path),
|
305
|
+
commands: record.commands
|
306
|
+
)
|
425
307
|
end
|
426
308
|
|
427
309
|
def build_record_path(name)
|
428
310
|
backspin_dir = configuration.backspin_dir
|
429
311
|
backspin_dir.mkpath
|
430
312
|
|
431
|
-
File.join(backspin_dir, "#{name}.
|
313
|
+
File.join(backspin_dir, "#{name}.yml")
|
432
314
|
end
|
433
315
|
end
|
434
316
|
end
|