backspin 0.4.5 → 0.6.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.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Backspin
4
+ # Handles matching logic between recorded and actual commands
5
+ class Matcher
6
+ attr_reader :config, :recorded_command, :actual_command
7
+
8
+ def initialize(config:, recorded_command:, actual_command:)
9
+ @config = normalize_config(config)
10
+ @recorded_command = recorded_command
11
+ @actual_command = actual_command
12
+ end
13
+
14
+ # @return [Boolean] true if commands match according to the configured matcher
15
+ def match?
16
+ if config.nil?
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
26
+ end
27
+
28
+ # @return [String] reason why matching failed
29
+ def failure_reason
30
+ reasons = []
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(", ")
61
+ end
62
+
63
+ private
64
+
65
+ def normalize_config(config)
66
+ return nil if config.nil?
67
+ return config if config.is_a?(Proc)
68
+
69
+ raise ArgumentError, "Matcher must be a Proc or Hash, got #{config.class}" unless config.is_a?(Hash)
70
+
71
+ # Validate hash keys and values
72
+ config.each do |key, value|
73
+ unless %i[all stdout stderr status].include?(key)
74
+ raise ArgumentError, "Invalid matcher key: #{key}. Must be one of: :all, :stdout, :stderr, :status"
75
+ end
76
+ raise ArgumentError, "Matcher for #{key} must be callable (Proc/Lambda)" unless value.respond_to?(:call)
77
+ end
78
+ config
79
+ end
80
+
81
+ def verify_with_hash_matcher
82
+ recorded_hash = recorded_command.to_h
83
+ actual_hash = actual_command.to_h
84
+
85
+ # Override-based: only run matchers that are explicitly provided
86
+ # Use map to ensure all matchers run, then check if all passed
87
+ results = config.map do |field, matcher_proc|
88
+ case field
89
+ when :all
90
+ matcher_proc.call(recorded_hash, actual_hash)
91
+ when :stdout, :stderr, :status
92
+ matcher_proc.call(recorded_hash[field.to_s], actual_hash[field.to_s])
93
+ else
94
+ # This should never happen due to normalize_config validation
95
+ raise ArgumentError, "Unknown field: #{field}"
96
+ end
97
+ end
98
+
99
+ results.all?
100
+ end
101
+
102
+ def default_matcher
103
+ @default_matcher ||= lambda do |recorded, actual|
104
+ recorded["stdout"] == actual["stdout"] &&
105
+ recorded["stderr"] == actual["stderr"] &&
106
+ recorded["status"] == actual["status"]
107
+ end
108
+ end
109
+ end
110
+ end
@@ -12,12 +12,13 @@ module Backspin
12
12
  include RSpec::Mocks::ExampleMethods
13
13
  SUPPORTED_COMMAND_TYPES = %i[capture3 system].freeze
14
14
 
15
- attr_reader :commands, :mode, :record, :options
15
+ attr_reader :commands, :mode, :record, :matcher, :filter
16
16
 
17
- def initialize(mode: :record, record: nil, options: {})
17
+ def initialize(mode: :record, record: nil, matcher: nil, filter: nil)
18
18
  @mode = mode
19
19
  @record = record
20
- @options = options
20
+ @matcher = matcher
21
+ @filter = filter
21
22
  @commands = []
22
23
  @playback_index = 0
23
24
  @command_diffs = []
@@ -45,14 +46,14 @@ module Backspin
45
46
  # Records registered commands, adds them to the record, saves the record, and returns the overall RecordResult
46
47
  def perform_recording
47
48
  result = yield
48
- record.save(filter: options[:filter])
49
+ record.save(filter: @filter)
49
50
  RecordResult.new(output: result, mode: :record, record: record)
50
51
  end
51
52
 
52
53
  # Performs verification by executing commands and comparing with recorded values
53
54
  def perform_verification
54
55
  raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
55
- raise RecordNotFoundError, "No commands found in record" if record.empty?
56
+ raise RecordNotFoundError, "No commands found in record #{record.path}" if record.empty?
56
57
 
57
58
  # Initialize tracking variables
58
59
  @command_diffs = []
@@ -76,7 +77,7 @@ module Backspin
76
77
  status: status.exitstatus
77
78
  )
78
79
 
79
- @command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: options[:matcher])
80
+ @command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: @matcher)
80
81
  @command_index += 1
81
82
  [stdout, stderr, status]
82
83
  end
@@ -96,7 +97,7 @@ module Backspin
96
97
  )
97
98
 
98
99
  # Create CommandDiff to track the comparison
99
- @command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: options[:matcher])
100
+ @command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: @matcher)
100
101
 
101
102
  @command_index += 1
102
103
  result
@@ -135,6 +136,150 @@ module Backspin
135
136
  )
136
137
  end
137
138
 
139
+ # Performs capture recording by intercepting all stdout/stderr output
140
+ def perform_capture_recording
141
+ require "tempfile"
142
+
143
+ # Create temporary files for capturing output
144
+ stdout_tempfile = Tempfile.new("backspin_stdout")
145
+ stderr_tempfile = Tempfile.new("backspin_stderr")
146
+
147
+ begin
148
+ # Save original file descriptors
149
+ original_stdout_fd = $stdout.dup
150
+ original_stderr_fd = $stderr.dup
151
+
152
+ # Redirect both Ruby IO and file descriptors
153
+ $stdout.reopen(stdout_tempfile)
154
+ $stderr.reopen(stderr_tempfile)
155
+
156
+ # Execute the block
157
+ result = yield
158
+
159
+ # Flush and read captured output
160
+ $stdout.flush
161
+ $stderr.flush
162
+ stdout_tempfile.rewind
163
+ stderr_tempfile.rewind
164
+
165
+ captured_stdout = stdout_tempfile.read
166
+ captured_stderr = stderr_tempfile.read
167
+
168
+ # Create a single command representing all captured output
169
+ command = Command.new(
170
+ method_class: Backspin::Capturer,
171
+ args: ["<captured block>"],
172
+ stdout: captured_stdout,
173
+ stderr: captured_stderr,
174
+ status: 0,
175
+ recorded_at: Time.now.iso8601
176
+ )
177
+
178
+ record.add_command(command)
179
+ record.save(filter: @filter)
180
+
181
+ RecordResult.new(output: result, mode: :record, record: record)
182
+ ensure
183
+ # Restore original file descriptors
184
+ $stdout.reopen(original_stdout_fd)
185
+ $stderr.reopen(original_stderr_fd)
186
+ original_stdout_fd.close
187
+ original_stderr_fd.close
188
+
189
+ # Clean up temp files
190
+ stdout_tempfile.close!
191
+ stderr_tempfile.close!
192
+ end
193
+ end
194
+
195
+ # Performs capture verification by capturing output and comparing with recorded values
196
+ def perform_capture_verification
197
+ raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
198
+ raise RecordNotFoundError, "No commands found in record #{record.path}" if record.empty?
199
+
200
+ require "tempfile"
201
+
202
+ # Create temporary files for capturing output
203
+ stdout_tempfile = Tempfile.new("backspin_stdout")
204
+ stderr_tempfile = Tempfile.new("backspin_stderr")
205
+
206
+ begin
207
+ # Save original file descriptors
208
+ original_stdout_fd = $stdout.dup
209
+ original_stderr_fd = $stderr.dup
210
+
211
+ # Redirect both Ruby IO and file descriptors
212
+ $stdout.reopen(stdout_tempfile)
213
+ $stderr.reopen(stderr_tempfile)
214
+
215
+ # Execute the block
216
+ output = yield
217
+
218
+ # Flush and read captured output
219
+ $stdout.flush
220
+ $stderr.flush
221
+ stdout_tempfile.rewind
222
+ stderr_tempfile.rewind
223
+
224
+ captured_stdout = stdout_tempfile.read
225
+ captured_stderr = stderr_tempfile.read
226
+
227
+ # Get the recorded command (should be only one for capture)
228
+ recorded_command = record.commands.first
229
+
230
+ # Create actual command from captured output
231
+ actual_command = Command.new(
232
+ method_class: Backspin::Capturer,
233
+ args: ["<captured block>"],
234
+ stdout: captured_stdout,
235
+ stderr: captured_stderr,
236
+ status: 0
237
+ )
238
+
239
+ # Create CommandDiff for comparison
240
+ command_diff = CommandDiff.new(
241
+ recorded_command: recorded_command,
242
+ actual_command: actual_command,
243
+ matcher: @matcher
244
+ )
245
+
246
+ RecordResult.new(
247
+ output: output,
248
+ mode: :verify,
249
+ verified: command_diff.verified?,
250
+ record: record,
251
+ command_diffs: [command_diff]
252
+ )
253
+ ensure
254
+ # Restore original file descriptors
255
+ $stdout.reopen(original_stdout_fd)
256
+ $stderr.reopen(original_stderr_fd)
257
+ original_stdout_fd.close
258
+ original_stderr_fd.close
259
+
260
+ # Clean up temp files
261
+ stdout_tempfile.close!
262
+ stderr_tempfile.close!
263
+ end
264
+ end
265
+
266
+ # Performs capture playback - executes block normally but could optionally suppress output
267
+ def perform_capture_playback
268
+ raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
269
+ raise RecordNotFoundError, "No commands found in record" if record.empty?
270
+
271
+ # For now, just execute the block normally
272
+ # In the future, we could optionally suppress output or return recorded output
273
+ output = yield
274
+
275
+ RecordResult.new(
276
+ output: output,
277
+ mode: :playback,
278
+ verified: true,
279
+ record: record
280
+ )
281
+ end
282
+
138
283
  private
139
284
 
140
285
  def setup_capture3_replay_stub
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Backspin
4
- VERSION = "0.4.5"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/backspin.rb CHANGED
@@ -7,8 +7,10 @@ require "pathname"
7
7
  require "ostruct"
8
8
  require "rspec/mocks"
9
9
  require "backspin/version"
10
+ require "backspin/configuration"
10
11
  require "backspin/command_result"
11
12
  require "backspin/command"
13
+ require "backspin/matcher"
12
14
  require "backspin/command_diff"
13
15
  require "backspin/record"
14
16
  require "backspin/recorder"
@@ -20,58 +22,6 @@ module Backspin
20
22
  # Include RSpec mocks methods
21
23
  extend RSpec::Mocks::ExampleMethods
22
24
 
23
- # Configuration for Backspin
24
- class Configuration
25
- attr_accessor :scrub_credentials
26
- # The directory where backspin will store its files - defaults to fixtures/backspin
27
- attr_accessor :backspin_dir
28
- # Regex patterns to scrub from saved output
29
- attr_reader :credential_patterns
30
-
31
- def initialize
32
- @scrub_credentials = true
33
- @credential_patterns = default_credential_patterns
34
- @backspin_dir = Pathname(Dir.pwd).join("fixtures", "backspin")
35
- end
36
-
37
- def add_credential_pattern(pattern)
38
- @credential_patterns << pattern
39
- end
40
-
41
- def clear_credential_patterns
42
- @credential_patterns = []
43
- end
44
-
45
- def reset_credential_patterns
46
- @credential_patterns = default_credential_patterns
47
- end
48
-
49
- private
50
-
51
- # Some default patterns for common credential types
52
- def default_credential_patterns
53
- [
54
- # AWS credentials
55
- /AKIA[0-9A-Z]{16}/, # AWS Access Key ID
56
- %r{aws_secret_access_key\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})["']?}i, # AWS Secret Key
57
- %r{aws_session_token\s*[:=]\s*["']?([A-Za-z0-9/+=]+)["']?}i, # AWS Session Token
58
-
59
- # Google Cloud credentials
60
- /AIza[0-9A-Za-z\-_]{35}/, # Google API Key
61
- /[0-9]+-[0-9A-Za-z_]{32}\.apps\.googleusercontent\.com/, # Google OAuth2 client ID
62
- /-----BEGIN (RSA )?PRIVATE KEY-----/, # Private keys
63
-
64
- # Generic patterns
65
- /api[_-]?key\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Generic API keys
66
- /auth[_-]?token\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Auth tokens
67
- /Bearer\s+([A-Za-z0-9\-_]+)/, # Bearer tokens
68
- /password\s*[:=]\s*["']?([^"'\s]{8,})["']?/i, # Passwords
69
- /-p([^"'\s]{8,})/, # MySQL-style password args
70
- /secret\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i # Generic secrets
71
- ]
72
- end
73
- end
74
-
75
25
  class << self
76
26
  def configuration
77
27
  return @configuration if @configuration
@@ -102,22 +52,21 @@ module Backspin
102
52
  # Primary API - records on first run, verifies on subsequent runs
103
53
  #
104
54
  # @param record_name [String] Name for the record file
105
- # @param options [Hash] Options for recording/verification
106
- # @option options [Symbol] :mode (:auto) Recording mode - :auto, :record, :verify, :playback
107
- # @option options [Proc] :filter Custom filter for recorded data
108
- # @option options [Proc, Hash] :matcher Custom matcher for verification
55
+ # @param mode [Symbol] Recording mode - :auto, :record, :verify, :playback
56
+ # @param matcher [Proc, Hash] Custom matcher for verification
109
57
  # - Proc: ->(recorded, actual) { ... } for full command matching
110
58
  # - Hash: { stdout: ->(recorded, actual) { ... }, stderr: ->(recorded, actual) { ... } } for field-specific matching
111
- # - Hash with :all key: { all: ->(recorded, actual) { ... }, stdout: ->(recorded, actual) { ... } } for combined matching
112
- # When both :all and field matchers are present, both must pass for verification to succeed.
113
- # Fields without specific matchers always use exact equality, regardless of :all presence.
59
+ # Only specified fields are checked - fields without matchers are ignored
60
+ # - Hash with :all key: { all: ->(recorded, actual) { ... } } receives full command hashes
61
+ # Can be combined with field matchers - all specified matchers must pass
62
+ # @param filter [Proc] Custom filter for recorded data
114
63
  # @return [RecordResult] Result object with output and status
115
- def run(record_name, options = {}, &block)
64
+ def run(record_name, mode: :auto, matcher: nil, filter: nil, &block)
116
65
  raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
117
66
  raise ArgumentError, "block is required" unless block_given?
118
67
 
119
68
  record_path = Record.build_record_path(record_name)
120
- mode = determine_mode(options[:mode], record_path)
69
+ mode = determine_mode(mode, record_path)
121
70
 
122
71
  # Create or load the record based on mode
123
72
  record = if mode == :record
@@ -127,7 +76,7 @@ module Backspin
127
76
  end
128
77
 
129
78
  # Create recorder with all needed context
130
- recorder = Recorder.new(record: record, options: options, mode: mode)
79
+ recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
131
80
 
132
81
  # Execute the appropriate mode
133
82
  case mode
@@ -146,11 +95,13 @@ module Backspin
146
95
  # Strict version of run that raises on verification failure
147
96
  #
148
97
  # @param record_name [String] Name for the record file
149
- # @param options [Hash] Options for recording/verification
98
+ # @param mode [Symbol] Recording mode - :auto, :record, :verify, :playback
99
+ # @param matcher [Proc, Hash] Custom matcher for verification
100
+ # @param filter [Proc] Custom filter for recorded data
150
101
  # @return [RecordResult] Result object with output and status
151
102
  # @raise [RSpec::Expectations::ExpectationNotMetError] If verification fails
152
- def run!(record_name, options = {}, &block)
153
- result = run(record_name, options, &block)
103
+ def run!(record_name, mode: :auto, matcher: nil, filter: nil, &block)
104
+ result = run(record_name, mode: mode, matcher: matcher, filter: filter, &block)
154
105
 
155
106
  if result.verified? == false
156
107
  error_message = "Backspin verification failed!\n"
@@ -165,6 +116,43 @@ module Backspin
165
116
  result
166
117
  end
167
118
 
119
+ # Captures all stdout/stderr output from a block
120
+ #
121
+ # @param record_name [String] Name for the record file
122
+ # @param mode [Symbol] Recording mode - :auto, :record, :verify, :playback
123
+ # @param matcher [Proc, Hash] Custom matcher for verification
124
+ # @param filter [Proc] Custom filter for recorded data
125
+ # @return [RecordResult] Result object with captured output
126
+ def capture(record_name, mode: :auto, matcher: nil, filter: nil, &block)
127
+ raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
128
+ raise ArgumentError, "block is required" unless block_given?
129
+
130
+ record_path = Record.build_record_path(record_name)
131
+ mode = determine_mode(mode, record_path)
132
+
133
+ # Create or load the record based on mode
134
+ record = if mode == :record
135
+ Record.create(record_name)
136
+ else
137
+ Record.load_or_create(record_path)
138
+ end
139
+
140
+ # Create recorder with all needed context
141
+ recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
142
+
143
+ # Execute the appropriate mode
144
+ case mode
145
+ when :record
146
+ recorder.perform_capture_recording(&block)
147
+ when :verify
148
+ recorder.perform_capture_verification(&block)
149
+ when :playback
150
+ recorder.perform_capture_playback(&block)
151
+ else
152
+ raise ArgumentError, "Unknown mode: #{mode}"
153
+ end
154
+ end
155
+
168
156
  private
169
157
 
170
158
  def determine_mode(mode_option, record_path)
data/release.rake CHANGED
@@ -4,7 +4,7 @@ require "bundler/gem_tasks"
4
4
 
5
5
  # Simplified release tasks using gem-release
6
6
  # Install with: gem install gem-release
7
-
7
+ # https://github.com/svenfuchs/gem-release
8
8
  namespace :release do
9
9
  desc "Release a new version (bump, tag, release)"
10
10
  task :version, [:level] do |t, args|
@@ -23,12 +23,13 @@ namespace :release do
23
23
  sh "git commit -am 'Bump version to #{new_version}'"
24
24
  sh "git push"
25
25
 
26
- sh "gem release --tag --github --push"
26
+ sh "gem release --tag --push"
27
+ Rake::Task["release:github"].invoke(new_version)
27
28
  end
28
29
 
29
- desc "Create GitHub release for current version"
30
- task :github do
31
- version = Backspin::VERSION
30
+ desc "Create GitHub release for specified version or current version"
31
+ task :github, [:version] do |t, args|
32
+ version = args[:version] || Backspin::VERSION
32
33
 
33
34
  if system("which gh > /dev/null 2>&1")
34
35
  puts "\nCreating GitHub release for v#{version}..."
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: backspin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.5
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rob Sanheim
@@ -13,44 +13,30 @@ dependencies:
13
13
  name: ostruct
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.5.0
18
+ version: '0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.5.0
25
+ version: '0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rspec-mocks
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '3.0'
32
+ version: '3'
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: '3.0'
40
- - !ruby/object:Gem::Dependency
41
- name: gem-release
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '2'
47
- type: :development
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '2'
39
+ version: '3'
54
40
  description: Backspin is a Ruby library for characterization testing of command-line
55
41
  interfaces. Inspired by VCR's cassette-based approach, it records and replays CLI
56
42
  interactions to make testing faster and more deterministic.
@@ -71,7 +57,7 @@ files:
71
57
  - Gemfile
72
58
  - Gemfile.lock
73
59
  - LICENSE.txt
74
- - MATCH_ON_USAGE.md
60
+ - MATCHERS.md
75
61
  - README.md
76
62
  - Rakefile
77
63
  - backspin.gemspec
@@ -138,6 +124,8 @@ files:
138
124
  - lib/backspin/command.rb
139
125
  - lib/backspin/command_diff.rb
140
126
  - lib/backspin/command_result.rb
127
+ - lib/backspin/configuration.rb
128
+ - lib/backspin/matcher.rb
141
129
  - lib/backspin/record.rb
142
130
  - lib/backspin/record_result.rb
143
131
  - lib/backspin/recorder.rb
@@ -165,7 +153,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
165
153
  - !ruby/object:Gem::Version
166
154
  version: '0'
167
155
  requirements: []
168
- rubygems_version: 3.6.9
156
+ rubygems_version: 3.6.7
169
157
  specification_version: 4
170
158
  summary: Record and replay CLI interactions for testing
171
159
  test_files: []