backspin 0.3.0 → 0.4.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.
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
@@ -69,7 +72,9 @@ module Backspin
69
72
 
70
73
  class << self
71
74
  def configuration
72
- @configuration ||= Configuration.new
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
- def call(record_name, filter: nil)
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
- # Create recorder to handle stubbing and command recording
182
- recorder = Recorder.new
183
- recorder.record_calls(:capture3, :system)
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
- yield
137
+ if result.verified? == false
138
+ error_message = "Backspin verification failed!\n"
139
+ error_message += "Record: #{result.record_path}\n"
186
140
 
187
- # Save commands using new format
188
- FileUtils.mkdir_p(File.dirname(record_path))
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
- Result.new(commands: recorder.commands, record_path: Pathname.new(record_path))
196
- end
144
+ raise RSpec::Expectations::ExpectationNotMetError, error_message
145
+ end
197
146
 
198
- def output
199
- last_output
147
+ result
200
148
  end
201
149
 
202
- def use_record(record_name, options = {}, &block)
203
- raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
150
+ private
204
151
 
205
- record_path = build_record_path(record_name)
206
- record_mode = options[:record] || :once
207
- filter = options[:filter]
208
-
209
- case record_mode
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 verify(record_name, mode: :strict, matcher: nil, &block)
236
- record_path = build_record_path(record_name)
159
+ def perform_recording(_record_name, record_path, options)
160
+ recorder = Recorder.new
161
+ recorder.record_calls(:capture3, :system)
237
162
 
238
- record = Record.load_or_create(record_path)
239
- unless record.exists?
240
- raise RecordNotFoundError, "Record not found: #{record_path}"
241
- end
163
+ output = yield
242
164
 
243
- if record.empty?
244
- raise RecordNotFoundError, "No commands found in record"
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
- # For verify, we only handle single command verification for now
248
- # Use the first command
249
- command = record.commands.first
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
- # Create recorder for verification
252
- recorder = Recorder.new
187
+ def perform_verification(_record_name, record_path, options)
188
+ record = Record.load_or_create(record_path)
253
189
 
254
- if mode == :playback
255
- # Playback mode: return recorded output without running command
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
- def verify!(record_name, mode: :strict, matcher: nil, &block)
322
- result = verify(record_name, mode: mode, matcher: matcher, &block)
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
- unless result.verified?
325
- error_message = "Backspin verification failed!\n"
326
- error_message += "Record: #{result.record_path}\n"
327
- error_message += "Expected output:\n#{result.expected_output}\n"
328
- error_message += "Actual output:\n#{result.actual_output}\n"
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 result.diff && !result.diff.empty?
331
- error_message += "Diff:\n#{result.diff}\n"
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 result.stderr_diff && !result.stderr_diff.empty?
335
- error_message += "Stderr diff:\n#{result.stderr_diff}\n"
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
- # Raise RSpec's expectation failure for proper integration
339
- raise RSpec::Expectations::ExpectationNotMetError, error_message
340
- end
213
+ # Execute the actual command
214
+ stdout, stderr, status = original_method.call(*args)
341
215
 
342
- result
343
- end
216
+ # Create verification result
217
+ actual_result = CommandResult.new(
218
+ stdout: stdout,
219
+ stderr: stderr,
220
+ status: status.exitstatus
221
+ )
344
222
 
345
- private
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
- def replay_record(record_path, &block)
348
- record = Record.load_or_create(record_path)
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
- if record.empty?
354
- raise RecordNotFoundError, "No commands found in record"
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
- # Create recorder in replay mode
358
- recorder = Recorder.new(mode: :replay, record: record)
359
- recorder.setup_replay_stubs
237
+ if recorded_command.nil?
238
+ raise RecordNotFoundError, "No more recorded commands, but tried to execute: system #{args.inspect}"
239
+ end
360
240
 
361
- block_return_value = yield
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
- # Return stdout, stderr, status if the block returned capture3 results
364
- # Otherwise return the block's return value
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
- def record_and_save_record(record_path, filter: nil, &block)
377
- # Create recorder to handle stubbing and command recording
378
- recorder = Recorder.new
379
- recorder.record_calls(:capture3, :system)
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
- block_return_value = yield
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
- # Save commands using new format
384
- FileUtils.mkdir_p(File.dirname(record_path))
385
- # Don't load existing data when creating new record
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
- # Return appropriate value
392
- if block_return_value.is_a?(Array) && block_return_value.size == 3
393
- # Return stdout, stderr, status as integers
394
- stdout, stderr, status = block_return_value
395
- [stdout, stderr, status.respond_to?(:exitstatus) ? status.exitstatus : status]
396
- else
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 record_new_episode(record_path, filter: nil, &block)
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
- # Create recorder to handle stubbing and command recording
407
- recorder = Recorder.new
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
- result = yield
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
- # Save all recordings (existing + new)
413
- if recorder.commands.any?
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
- # Return appropriate value
419
- if result.is_a?(Array) && result.size == 3
420
- stdout, stderr, status = result
421
- [stdout, stderr, status.respond_to?(:exitstatus) ? status.exitstatus : status]
422
- else
423
- result
424
- end
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}.yaml")
313
+ File.join(backspin_dir, "#{name}.yml")
432
314
  end
433
315
  end
434
316
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: backspin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rob Sanheim
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -42,8 +42,7 @@ description: Backspin is a Ruby library for characterization testing of command-
42
42
  interactions to make testing faster and more deterministic.
43
43
  email:
44
44
  - rsanheim@gmail.com
45
- executables:
46
- - setup
45
+ executables: []
47
46
  extensions: []
48
47
  extra_rdoc_files: []
49
48
  files:
@@ -60,10 +59,15 @@ files:
60
59
  - README.md
61
60
  - Rakefile
62
61
  - backspin.gemspec
62
+ - bin/rake
63
+ - bin/rspec
63
64
  - bin/setup
64
65
  - lib/backspin.rb
65
66
  - lib/backspin/command.rb
67
+ - lib/backspin/command_diff.rb
68
+ - lib/backspin/command_result.rb
66
69
  - lib/backspin/record.rb
70
+ - lib/backspin/record_result.rb
67
71
  - lib/backspin/recorder.rb
68
72
  - lib/backspin/version.rb
69
73
  homepage: https://github.com/rsanheim/backspin