backspin 0.2.1 → 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
@@ -43,6 +46,7 @@ module Backspin
43
46
 
44
47
  private
45
48
 
49
+ # Some default patterns for common credential types
46
50
  def default_credential_patterns
47
51
  [
48
52
  # AWS credentials
@@ -58,7 +62,9 @@ module Backspin
58
62
  # Generic patterns
59
63
  /api[_-]?key\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Generic API keys
60
64
  /auth[_-]?token\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Auth tokens
65
+ /Bearer\s+([A-Za-z0-9\-_]+)/, # Bearer tokens
61
66
  /password\s*[:=]\s*["']?([^"'\s]{8,})["']?/i, # Passwords
67
+ /-p([^"'\s]{8,})/, # MySQL-style password args
62
68
  /secret\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i # Generic secrets
63
69
  ]
64
70
  end
@@ -66,7 +72,9 @@ module Backspin
66
72
 
67
73
  class << self
68
74
  def configuration
69
- @configuration ||= Configuration.new
75
+ return @configuration if @configuration
76
+
77
+ @configuration = Configuration.new
70
78
  end
71
79
 
72
80
  def configure
@@ -76,86 +84,6 @@ module Backspin
76
84
  def reset_configuration!
77
85
  @configuration = Configuration.new
78
86
  end
79
- end
80
-
81
- class Result
82
- attr_reader :commands, :record_path
83
-
84
- def initialize(commands:, record_path:)
85
- @commands = commands
86
- @record_path = record_path
87
- end
88
- end
89
-
90
- class VerifyResult
91
- attr_reader :record_path, :expected_output, :actual_output, :diff, :stderr_diff
92
-
93
- def initialize(verified:, record_path:, expected_output: nil, actual_output: nil,
94
- expected_stderr: nil, actual_stderr: nil, expected_status: nil, actual_status: nil,
95
- command_executed: true)
96
- @verified = verified
97
- @record_path = record_path
98
- @expected_output = expected_output
99
- @actual_output = actual_output
100
- @expected_stderr = expected_stderr
101
- @actual_stderr = actual_stderr
102
- @expected_status = expected_status
103
- @actual_status = actual_status
104
- @command_executed = command_executed
105
-
106
- if !verified && expected_output && actual_output
107
- @diff = generate_diff(expected_output, actual_output)
108
- end
109
-
110
- if !verified && expected_stderr && actual_stderr && expected_stderr != actual_stderr
111
- @stderr_diff = generate_diff(expected_stderr, actual_stderr)
112
- end
113
- end
114
-
115
- def verified?
116
- @verified
117
- end
118
-
119
- def output
120
- @actual_output
121
- end
122
-
123
- def error_message
124
- return nil if verified?
125
-
126
- "Output verification failed\nExpected: #{@expected_output.chomp}\nActual: #{@actual_output.chomp}"
127
- end
128
-
129
- def command_executed?
130
- @command_executed
131
- end
132
-
133
- private
134
-
135
- def generate_diff(expected, actual)
136
- expected_lines = expected.lines
137
- actual_lines = actual.lines
138
- diff = []
139
-
140
- # Simple diff for now
141
- expected_lines.each do |line|
142
- unless actual_lines.include?(line)
143
- diff << "-#{line.chomp}"
144
- end
145
- end
146
-
147
- actual_lines.each do |line|
148
- unless expected_lines.include?(line)
149
- diff << "+#{line.chomp}"
150
- end
151
- end
152
-
153
- diff.join("\n")
154
- end
155
- end
156
-
157
- class << self
158
- attr_accessor :last_output
159
87
 
160
88
  def scrub_text(text)
161
89
  return text unless configuration.scrub_credentials && text
@@ -170,262 +98,219 @@ module Backspin
170
98
  scrubbed
171
99
  end
172
100
 
173
- 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)
174
110
  raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
111
+ raise ArgumentError, "block is required" unless block_given?
175
112
 
176
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
177
127
 
178
- # Create recorder to handle stubbing and command recording
179
- recorder = Recorder.new
180
- 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)
181
136
 
182
- yield
137
+ if result.verified? == false
138
+ error_message = "Backspin verification failed!\n"
139
+ error_message += "Record: #{result.record_path}\n"
183
140
 
184
- # Save commands using new format
185
- FileUtils.mkdir_p(File.dirname(record_path))
186
- # Don't load existing data when creating new record
187
- record = Record.new(record_path)
188
- record.clear # Clear any loaded data
189
- recorder.commands.each { |cmd| record.add_command(cmd) }
190
- 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
191
143
 
192
- Result.new(commands: recorder.commands, record_path: Pathname.new(record_path))
193
- end
144
+ raise RSpec::Expectations::ExpectationNotMetError, error_message
145
+ end
194
146
 
195
- def output
196
- last_output
147
+ result
197
148
  end
198
149
 
199
- def use_record(record_name, options = {}, &block)
200
- raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
150
+ private
201
151
 
202
- record_path = build_record_path(record_name)
203
- record_mode = options[:record] || :once
204
- filter = options[:filter]
205
-
206
- case record_mode
207
- when :none
208
- # Never record, only replay
209
- unless File.exist?(record_path)
210
- raise RecordNotFoundError, "Record not found: #{record_path}"
211
- end
212
- replay_record(record_path, &block)
213
- when :all
214
- # Always record
215
- record_and_save_record(record_path, filter: filter, &block)
216
- when :once
217
- # Record if doesn't exist, replay if exists
218
- if File.exist?(record_path)
219
- replay_record(record_path, &block)
220
- else
221
- record_and_save_record(record_path, filter: filter, &block)
222
- end
223
- when :new_episodes
224
- # Record new commands not in record
225
- # For now, simplified: just append new recordings
226
- record_new_episode(record_path, filter: filter, &block)
227
- else
228
- raise ArgumentError, "Unknown record mode: #{record_mode}"
229
- 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
230
157
  end
231
158
 
232
- def verify(record_name, mode: :strict, matcher: nil, &block)
233
- 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)
234
162
 
235
- record = Record.load_or_create(record_path)
236
- unless record.exists?
237
- raise RecordNotFoundError, "Record not found: #{record_path}"
238
- end
163
+ output = yield
239
164
 
240
- if record.empty?
241
- 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]
242
169
  end
243
170
 
244
- # For verify, we only handle single command verification for now
245
- # Use the first command
246
- 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
247
186
 
248
- # Create recorder for verification
249
- recorder = Recorder.new
187
+ def perform_verification(_record_name, record_path, options)
188
+ record = Record.load_or_create(record_path)
250
189
 
251
- if mode == :playback
252
- # Playback mode: return recorded output without running command
253
- recorder.setup_playback_stub(command)
254
-
255
- yield
256
-
257
- # In playback mode, always verified
258
- VerifyResult.new(
259
- verified: true,
260
- record_path: Pathname.new(record_path),
261
- expected_output: command.stdout,
262
- actual_output: command.stdout,
263
- expected_stderr: command.stderr,
264
- actual_stderr: command.stderr,
265
- expected_status: command.status,
266
- actual_status: command.status,
267
- command_executed: false
268
- )
269
- elsif matcher
270
- # Custom matcher verification
271
- recorder.setup_verification_stub(command)
272
-
273
- yield
274
-
275
- # Call custom matcher - convert command back to hash format for matcher
276
- recorded_data = command.to_h
277
- verified = matcher.call(recorded_data, recorder.verification_data)
278
-
279
- VerifyResult.new(
280
- verified: verified,
281
- record_path: Pathname.new(record_path),
282
- expected_output: command.stdout,
283
- actual_output: recorder.verification_data["stdout"],
284
- expected_stderr: command.stderr,
285
- actual_stderr: recorder.verification_data["stderr"],
286
- expected_status: command.status,
287
- actual_status: recorder.verification_data["status"]
288
- )
289
- else
290
- # Default strict mode
291
- recorder.setup_verification_stub(command)
292
-
293
- yield
294
-
295
- # Compare outputs
296
- actual_stdout = recorder.verification_data["stdout"]
297
- actual_stderr = recorder.verification_data["stderr"]
298
- actual_status = recorder.verification_data["status"]
299
-
300
- verified =
301
- command.stdout == actual_stdout &&
302
- command.stderr == actual_stderr &&
303
- command.status == actual_status
304
-
305
- VerifyResult.new(
306
- verified: verified,
307
- record_path: Pathname.new(record_path),
308
- expected_output: command.stdout,
309
- actual_output: actual_stdout,
310
- expected_stderr: command.stderr,
311
- actual_stderr: actual_stderr,
312
- expected_status: command.status,
313
- actual_status: actual_status
314
- )
315
- end
316
- end
190
+ raise RecordNotFoundError, "Record not found: #{record_path}" unless record.exists?
191
+ raise RecordNotFoundError, "No commands found in record" if record.empty?
317
192
 
318
- def verify!(record_name, mode: :strict, matcher: nil, &block)
319
- 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
320
196
 
321
- unless result.verified?
322
- error_message = "Backspin verification failed!\n"
323
- error_message += "Record: #{result.record_path}\n"
324
- error_message += "Expected output:\n#{result.expected_output}\n"
325
- 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]
326
204
 
327
- if result.diff && !result.diff.empty?
328
- 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}"
329
207
  end
330
208
 
331
- if result.stderr_diff && !result.stderr_diff.empty?
332
- 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"
333
211
  end
334
212
 
335
- # Raise RSpec's expectation failure for proper integration
336
- raise RSpec::Expectations::ExpectationNotMetError, error_message
337
- end
213
+ # Execute the actual command
214
+ stdout, stderr, status = original_method.call(*args)
338
215
 
339
- result
340
- end
216
+ # Create verification result
217
+ actual_result = CommandResult.new(
218
+ stdout: stdout,
219
+ stderr: stderr,
220
+ status: status.exitstatus
221
+ )
341
222
 
342
- 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
+ )
343
229
 
344
- def replay_record(record_path, &block)
345
- record = Record.load_or_create(record_path)
346
- unless record.exists?
347
- raise RecordNotFoundError, "Record not found: #{record_path}"
230
+ command_index += 1
231
+ [stdout, stderr, status]
348
232
  end
349
233
 
350
- if record.empty?
351
- raise RecordNotFoundError, "No commands found in record"
352
- 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]
353
236
 
354
- # Create recorder in replay mode
355
- recorder = Recorder.new(mode: :replay, record: record)
356
- 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
357
240
 
358
- 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
359
244
 
360
- # Return stdout, stderr, status if the block returned capture3 results
361
- # Otherwise return the block's return value
362
- if block_return_value.is_a?(Array) && block_return_value.size == 3 &&
363
- block_return_value[0].is_a?(String) && block_return_value[1].is_a?(String)
364
- # Convert status to integer for consistency
365
- stdout, stderr, status = block_return_value
366
- status_int = status.respond_to?(:exitstatus) ? status.exitstatus : status
367
- [stdout, stderr, status_int]
368
- else
369
- block_return_value
370
- end
371
- end
245
+ # Execute the actual command
246
+ result = original_method.call(receiver, *args)
372
247
 
373
- def record_and_save_record(record_path, filter: nil, &block)
374
- # Create recorder to handle stubbing and command recording
375
- recorder = Recorder.new
376
- 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
+ )
377
254
 
378
- 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
+ )
379
261
 
380
- # Save commands using new format
381
- FileUtils.mkdir_p(File.dirname(record_path))
382
- # Don't load existing data when creating new record
383
- record = Record.new(record_path)
384
- record.clear # Clear any loaded data
385
- recorder.commands.each { |cmd| record.add_command(cmd) }
386
- record.save(filter: filter)
262
+ command_index += 1
263
+ result
264
+ end
387
265
 
388
- # Return appropriate value
389
- if block_return_value.is_a?(Array) && block_return_value.size == 3
390
- # Return stdout, stderr, status as integers
391
- stdout, stderr, status = block_return_value
392
- [stdout, stderr, status.respond_to?(:exitstatus) ? status.exitstatus : status]
393
- else
394
- 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"
395
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
+ )
396
285
  end
397
286
 
398
- def record_new_episode(record_path, filter: nil, &block)
399
- # For new_episodes mode, we'd need to track which commands have been seen
400
- # For now, simplified implementation that just appends
287
+ def perform_playback(_record_name, record_path, _options)
401
288
  record = Record.load_or_create(record_path)
402
289
 
403
- # Create recorder to handle stubbing and command recording
404
- recorder = Recorder.new
405
- 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?
406
292
 
407
- 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
408
296
 
409
- # Save all recordings (existing + new)
410
- if recorder.commands.any?
411
- recorder.commands.each { |cmd| record.add_command(cmd) }
412
- record.save(filter: filter)
413
- end
297
+ # Execute block (all commands will be stubbed with recorded values)
298
+ output = yield
414
299
 
415
- # Return appropriate value
416
- if result.is_a?(Array) && result.size == 3
417
- stdout, stderr, status = result
418
- [stdout, stderr, status.respond_to?(:exitstatus) ? status.exitstatus : status]
419
- else
420
- result
421
- 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
+ )
422
307
  end
423
308
 
424
309
  def build_record_path(name)
425
310
  backspin_dir = configuration.backspin_dir
426
311
  backspin_dir.mkpath
427
312
 
428
- File.join(backspin_dir, "#{name}.yaml")
313
+ File.join(backspin_dir, "#{name}.yml")
429
314
  end
430
315
  end
431
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.2.1
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:
@@ -27,40 +27,47 @@ dependencies:
27
27
  name: ostruct
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - ">="
30
+ - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: 0.5.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - ">="
37
+ - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0'
39
+ version: 0.5.0
40
40
  description: Backspin is a Ruby library for characterization testing of command-line
41
41
  interfaces. Inspired by VCR's cassette-based approach, it records and replays CLI
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:
49
+ - ".circleci/config.yml"
50
50
  - ".gitignore"
51
51
  - ".rspec"
52
52
  - ".standard.yml"
53
53
  - CHANGELOG.md
54
54
  - CLAUDE.md
55
+ - CONTRIBUTING.md
55
56
  - Gemfile
57
+ - Gemfile.lock
56
58
  - LICENSE.txt
57
59
  - README.md
58
60
  - Rakefile
59
61
  - backspin.gemspec
62
+ - bin/rake
63
+ - bin/rspec
60
64
  - bin/setup
61
65
  - lib/backspin.rb
62
66
  - lib/backspin/command.rb
67
+ - lib/backspin/command_diff.rb
68
+ - lib/backspin/command_result.rb
63
69
  - lib/backspin/record.rb
70
+ - lib/backspin/record_result.rb
64
71
  - lib/backspin/recorder.rb
65
72
  - lib/backspin/version.rb
66
73
  homepage: https://github.com/rsanheim/backspin
@@ -77,14 +84,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
77
84
  requirements:
78
85
  - - ">="
79
86
  - !ruby/object:Gem::Version
80
- version: 2.5.0
87
+ version: 3.1.0
81
88
  required_rubygems_version: !ruby/object:Gem::Requirement
82
89
  requirements:
83
90
  - - ">="
84
91
  - !ruby/object:Gem::Version
85
92
  version: '0'
86
93
  requirements: []
87
- rubygems_version: 3.6.7
94
+ rubygems_version: 3.6.9
88
95
  specification_version: 4
89
96
  summary: Record and replay CLI interactions for testing
90
97
  test_files: []